iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

今天我們把首頁做成一個單頁 Demo,選圖、三顆按鈕(灰階/模糊/銳化)、即時顯示尺寸與處理時間,並且沿用前幾天做好的零拷貝資料流錯誤訊息協議。可以把下面整包貼進新資料夾,輕輕鬆鬆。

清晰的解釋以下流程:

  1. 怎麼載入 .wasm;2. 怎麼把像素丟進去、把結果拿回來;3. 這一來一回要花多少時間。

專案骨架

package.json(固定 ESM,鎖住入口與 dev script)

{
  "name": "rustwasm-test",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "img-wasm": "^0.1.0"
  },
  "devDependencies": {
    "vite": "^5.4.0",
    "typescript": "^5.4.0"
  }
}

接著放兩個檔:index.htmlmain.ts。注意:wasm-pack 預設會輸出 rusttest_wasm_bg.wasm;如果發佈時改過 --out-name下方 ?url 路徑要一起改

index.html

選圖/三顆按鈕/資訊列

<!doctype html>
<html lang="zh-Hant">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>img-wasm demo</title>
    <style>
      body { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 24px; }
      #toolbar { display:flex; gap:12px; align-items:center; flex-wrap: wrap; }
      #stats { margin-top:8px; color:#555; }
      canvas { display:block; margin-top:12px; max-width:100%; border-radius:8px; }
      button:disabled { opacity:.5; cursor:not-allowed; }
      .pill { padding:2px 8px; border-radius:999px; background:#f3f3f3; margin-right:8px; }
      #error { margin-top:8px; color:#b00020; white-space:pre-wrap; }
    </style>
  </head>
  <body>
    <div id="toolbar">
      <input id="pick" type="file" accept="image/*" />
      <button id="btn-gray" disabled>灰階</button>
      <button id="btn-blur" disabled>模糊 r=2</button>
      <button id="btn-sharp" disabled>銳化</button>
      <span class="pill" id="dim">–</span>
      <span class="pill" id="timing">–</span>
      <span class="pill" id="wasm">WASM:載入中…</span>
    </div>
    <div id="stats"></div>
    <div id="error"></div>
    <canvas id="cv"></canvas>

    <script type="module" src="/main.ts"></script>
  </body>
</html>

main.ts

這份程式做了四件事:

  1. 初始化 Wasm:用 ESM 指向套件裡的 .wasm 檔;
  2. 載入圖片到 Canvas
  3. 把像素一次拷入 Wasm 常駐緩衝、在 Wasm 內跑完整管線,再一次貼回
  4. 量測毫秒,顯示尺寸與版本,錯誤則用 {code,message,hint} 呈現。
import init, {
  memory,
  ensure_buffer,
  buffer_ptr,
  buffer_len,
  load_pixels,
  run_pipeline_inplace,
} from 'img-wasm'
import wasmUrl from 'rustwasm-test/rustwasm-test_bg.wasm?url'

await init({ module_or_path: wasmUrl })

const cv = document.querySelector<HTMLCanvasElement>('#cv')!
const ctx = cv.getContext('2d', { willReadFrequently: true })!
const pick = document.querySelector<HTMLInputElement>('#pick')!
const btnGray = document.querySelector<HTMLButtonElement>('#btn-gray')!
const btnBlur = document.querySelector<HTMLButtonElement>('#btn-blur')!
const btnSharp = document.querySelector<HTMLButtonElement>('#btn-sharp')!
const pillDim = document.querySelector<HTMLSpanElement>('#dim')!
const pillTiming = document.querySelector<HTMLSpanElement>('#timing')!
const pillWasm = document.querySelector<HTMLSpanElement>('#wasm')!
const errorEl = document.querySelector<HTMLDivElement>('#error')!

pillWasm.textContent = 'WASM:ready'
let W = 0, H = 0

function toJsError(e: any) {
  const code = e?.code ?? e?.error?.code ?? 'E_UNKNOWN'
  const message = e?.message ?? e?.error?.message ?? String(e)
  const hint = e?.hint ?? e?.error?.hint
  const err = new Error(message) as Error & { code: string; hint?: string }
  err.code = code
  if (hint) (err as any).hint = hint
  return err
}

function setButtons(enabled: boolean) {
  btnGray.disabled = btnBlur.disabled = btnSharp.disabled = !enabled
}

function showDims() {
  pillDim.textContent = W ? `${W}×${H}` : '–'
}

function measure<T>(fn: () => T) {
  const t0 = performance.now()
  const ret = fn()
  const ms = performance.now() - t0
  return { ret, ms }
}

// 載圖 → 畫到 canvas
pick.onchange = () => {
  const file = pick.files?.[0]; if (!file) return
  const url = URL.createObjectURL(file)
  const img = new Image()
  img.onload = () => {
    W = img.naturalWidth; H = img.naturalHeight
    cv.width = W; cv.height = H
    ctx.drawImage(img, 0, 0)
    showDims()
    setButtons(true)
    URL.revokeObjectURL(url)
    errorEl.textContent = ''
    pillTiming.textContent = '–'
  }
  img.src = url
}

// 把像素一次放進 WASM,算完再一次貼回
async function runInplace(ops: unknown[]) {
  if (!W || !H) return
  errorEl.textContent = ''
  try {
    const imgData = ctx.getImageData(0, 0, W, H)

    // JS→WASM
    const bytes = new Uint8Array(imgData.data.buffer) // 與 ClampedArray 共用底層
    ensure_buffer(bytes.length)
    load_pixels(bytes)

    // 在 WASM 內跑完整管線
    const { ms } = measure(() => {
      run_pipeline_inplace(W, H, ops)
    })

    // WASM → JS
    const ptr = buffer_ptr()
    const len = buffer_len()
    const view = new Uint8Array((memory as WebAssembly.Memory).buffer, ptr, len)
    imgData.data.set(view)
    ctx.putImageData(imgData, 0, 0)

    pillTiming.textContent = `${ms.toFixed(2)} ms`
  } catch (e) {
    const err = toJsError(e)
    errorEl.textContent = `[${(err as any).code}] ${err.message}${(err as any).hint ? '\n' + (err as any).hint : ''}`
  }
}

// 灰階/模糊/銳化
btnGray.onclick = () => runInplace([{ kind: 'grayscale' }])
btnBlur.onclick  = () => runInplace([{ kind: 'blur', r: 2 }])
btnSharp.onclick = () => runInplace([{ kind: 'conv3x3', k: [0,-1,0, -1,5,-1, 0,-1,0] }])

啟動

pnpm dev

為什麼這樣長相?

  • wasmUrl?url 讓 bundler 在建置時把 .wasm 當靜態資源處理,瀏覽器可直接抓。
  • ensure_buffer → load_pixels → run_pipeline_inplace → buffer_ptr/len 這條鏈,確保整段只發生兩次拷貝(一次進、一次出),符合 Day 16 的零拷貝資料流目標。
  • 每次呼叫 ensure_buffer 之後 再用 buffer_ptr/len 取 view,避免 memory.grow 造成舊視圖失效(Day 19 已經把這條規則寫進錯誤碼的 hint)。

上一篇
Day 19|把錯誤變成導航:DX 的三把螺絲
下一篇
Day 21|Node 端策略(支援,或是乾脆不支援)
系列文
把前端加速到天花板:Rust+WASM 即插即用外掛26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言